查看原文
其他

fastjson2为什么这么快?

严彬源(泰文) 阿里开发者 2023-02-27

阿里妹导读


本文作者从以下三个方面讲述了fastjson2 使用了哪些核心技术来提升速度。

1、用「Lambda 生成函数映射」代替「高频的反射操作」

2、对 String 做零拷贝优化

3、常见类型解析优化

fastjson 是很多企业应用中处理 json 数据的基础工具,其凭借在易用性、处理速度上的优越性,支撑了很多数据处理场景。fastjson 的作者「高铁」已更新推出 2.0 版本的 fastjson,即 fastjosn2[1]
据 “相关数据” [2]显示,fastjson2 各方面性能均有提升,常规数据序列化相比 1.0 系列提升达到 30%,那么,fastjson2 使用了哪些核心技术来提升速度的呢?笔者总结包含但不限于以下几个方面:
  • 用「Lambda 生成函数映射」代替「高频的反射操作」

  • 对 String 做零拷贝优化

  • 常见类型解析优化

一、用「 Lambda 生成函数映射」代替「高频的反射操作」

我们来看一段最简单的反射执行代码:
public class Bean { int id; public int getId() { return id; }}
Method methodGetId = Bean.class.getMethod("getId");Bean bean = createInstance();int value = (Integer) methodGetId.invoke(bean);

上面的反射执行代码可以被改写成这样:

// 将getId()映射为function函数java.util.function.ToIntFunction<Bean> function = Bean::getId; int i = function.applyAsInt(bean);

fastjson2 中的具体实现的要复杂一点,但本质上跟上面一样,其本质也是生成了一个 function。

//functionjava.util.function.ToIntFunction<Bean> function = LambdaMetafactory.metafactory( lookup, "applyAsInt", methodHanlder, methodType(ToIntFunction.class), lookup.findVirtual(int.class, "getId", methodType(int.class)), methodType(int.class));int i = function.applyAsInt(bean);
我们使用反射获取到的 Method 和 Lambda 函数分别执行 10000 次来看下处理速度差异:
Method invoke elapsed: 25msBean::getId elapsed: 1ms

处理速度相差居然达到 25 倍,使用 Java8 Lambda 为什么能提升这多呢?

答案就是:Lambda 利用 LambdaMetafactory 生成了函数映射代替反射。
下面我们详细分析下 Java反射 与 Lambda 函数映射 的底层区别。

1、反射执行的底层原理

注:以下只是想表达出反射调用本身的繁杂性,大可不必深究这些代码细节
从代码角度,我们从 Java 方法反射 Method.invoke 的源码入口来深入:
public Object invoke(Object obj, Object... args) throws IllegalAccessException, IllegalArgumentException,InvocationTargetException{ if (!override) { if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) { Class<?> caller = Reflection.getCallerClass(); checkAccess(caller, clazz, obj, modifiers); } } MethodAccessor ma = methodAccessor;// read volatile if (ma == null) ma = acquireMethodAccessor(); return ma.invoke(obj, args);}

可见,经过简单的检查后,调用的是MethodAccessor.invoke(),这部分的实际实现:

public Object invoke(Object var1, Object[] var2) throws IllegalArgumentException, InvocationTargetException { if (++this.numInvocations > ReflectionFactory.inflationThreshold() && !ReflectUtil.isVMAnonymousClass(this.method.getDeclaringClass())) { MethodAccessorImpl var3 = (MethodAccessorImpl)(new MethodAccessorGenerator()).generateMethod(this.method.getDeclaringClass(), this.method.getName(), this.method.getParameterTypes(), this.method.getReturnType(), this.method.getExceptionTypes(), this.method.getModifiers()); this.parent.setDelegate(var3); } return invoke0(this.method, var1, var2);}
private static native Object invoke0(Method var0, Object var1, Object[] var2);

可见,最终调用的是 native 本地方法(本地方法栈)的 invoke0(),这部分的实现:

JNIEXPORT jobject JNICALL Java_sun_reflect_NativeMethodAccessorImpl_invoke0(JNIEnv *env, jclass unused, jobject m, jobject obj, jobjectArray args){ return JVM_InvokeMethod(env, m, obj, args);}

可见,调用的是 jvm.h 模块的 JVM_InvokeMethod 方法,这部分的实现:

JNIEXPORT jobject JNICALL Java_sun_reflect_NativeMethodAccessorImpl_invoke0(JNIEnv *env, jclass unused, jobject m, jobject obj, jobjectArray args){ return JVM_InvokeMethod(env, m, obj, args);}
更详细的细节:https://www.zhihu.com/question/464985077/answer/1940021614

2、Lambda生成函数映射的底层原理

具体来讲,Bean::getId 这种 Lambda 写法进过编译后,会通过 java.lang.invoke.LambdaMetafactory 

调用到

java.lang.invoke.InnerClassLambdaMetafactory#spinInnerClass

最终实现是调用 JDK 自带的字节码库 jdk.internal.org.objectweb.asm 动态生成一个内部类,上层 call 内部类的方法执行调用。
所以 Lambda 生成函数映射的方式,核心消耗就在于生成函数映射,那生成函数映射的效率究竟如何呢?
我们和反射获取 Method 做个对比,Benchmark 结论:
Benchmark 
Mode
Cnt
Score
Error
Units
genMethod(反射获取方法)
avgt(平均耗时)
5
0.125
0.015
us/op
genLambda(生成方法的函数映射)
avgt
5
51.880
40.040
us/op
从数据来看,生成函数映射的耗时远高于反射获取 Method。那为我们不禁要问,既然生成函数映射的性能远低于反射获取方法,那为什么最终用生成函数的方式的执行速度比反射要快?
答案就在于——函数复用,将一个固定签名的函数缓存起来,下次调用就可以省去函数创建的过程。
比如 fastjson2 直接将常用函数的初始化缓存放在 static 代码块,这就将函数创建的消耗就被前置到类加载阶段,在数据处理阶段的耗时进一步降低。

3、对比分析 & 结论

从原理上来说,反射方式,在获取 Method 阶段消耗较少,但 invoke 阶段则是每次都用都调用本地方法执行,先是在 jvm 层面多出一些检查,而后转到 JNI 本地库,除了有额外的 jvm 堆栈与本地方法栈的 context 交换 ,还多出一系列 C 体系额外操作,在性能上自然是不如 Lambda 函数映射;
Lambda 生成函数映射的方式,在生成代理类的过程中有部分开销,这部分开销可以通过缓存机制大量减少,而后的调用则全部属于 Java 范畴内的堆栈调用(即拿到代理类后,调用效率和原生方法调用几乎一致)。

二、对 String 做零拷贝优化

1、何为零拷贝

零拷贝[3]是老生常谈的问题,Kafka 和 Netty 等都用到了零拷贝的知识,这里简单介绍一下其概念以便生疏的读者理解上流畅。
零拷贝:是指计算机执行IO操作时,CPU不需要将数据从一个存储区域复制到另一个存储区域,进而减少上下文切换以及CPU的拷贝时间。它是一种IO操作优化技术。
JDK8 中的 String 是如何拷贝的?
为了实现字符串是不可变的特性,JDK 在构造 String 构造字符串的时候,会有拷贝的过程,比如上图是 JDK8 的 String 的一个构造函数的实现,其在堆内存中重新开辟了一块内存区域。
如果要提升构造字符串的开销,就要避免这样的拷贝,即零拷贝。

2、fastjson2 中如何实现 0 拷贝

在 JDK8 中,String 有一个构造函数是不做拷贝的:
但这个方法不是 public,不能直接访问到,可以反射执行,也可以使用 LambdaMetafactory 创建函数映射来调用,前面有介绍这个技巧。
生成的函数映射可以缓存起来复用,而这个构造方法的签名是固定不变的,这意味着,只需要生成一次,后续所有需要初始化 String 的时候都可以复用。

3、fastjson2 中的应用

将 LocalDate 格式化为 “yyyy-MM-dd” 的 String 源码(注:针对 JDK8 的实现,此处对源码精简整理以方便阅读):
static BiFunction<char[], Boolean, String> STRING_CREATOR_JDK8;static { //为上述String的0拷贝构造方法创建一个映射函数 CallSite callSite = LambdaMetafactory.metafactory(caller, "apply", methodType(BiFunction.class), methodType(Object.class, Object.class, Object.class), handle, methodType(String.class, char[].class, boolean.class)); STRING_CREATOR_JDK8 = (BiFunction<char[], Boolean, String>) callSite.getTarget().invokeExact();}
static String formatYYYYMMDD(LocalDate date) { int year = date.getYear(); int month = date.getMonthValue(); int dayOfMonth = date.getDayOfMonth(); int y0 = year / 1000 + '0'; int y1 = (year / 100) % 10 + '0'; int y2 = (year / 10) % 10 + '0'; int y3 = year % 10 + '0'; int m0 = month / 10 + '0'; int m1 = month % 10 + '0'; int d0 = dayOfMonth / 10 + '0'; int d1 = dayOfMonth % 10 + '0';
//char array char[] chars = new char[10]; chars[0] = (char) y1; chars[1] = (char) y2; chars[2] = (char) y3; chars[3] = (char) y4; chars[4] = '-'; chars[5] = (char) m0; chars[6] = (char) m1; chars[7] = '-'; chars[8] = (char) d0; chars[9] = (char) d1;
//执行「lambda函数映射」构造String String str = STRING_CREATOR_JDK8.apply(chars, Boolean.TRUE); return str;}

在 JDK8 的实现中,先拼接好格式中每一个 char 字符,然后通过零拷贝的方式构造字符串对象,这样就实现了快速格式化 LocalDate 到 String,这样的实现远比使用 SimpleDateFormat 之类要快。这种实例化 String 的方式在fatsjson2 中的 JSONReader、JSONWritter 随处可见。

三、常见类型解析优化

fastjson2 里针对各种类型的优化处理很多,不能一一列举,这里仅以 Date 类型举例,我们前面举例了将 Date 格式化为 String,这次我们反过来,将 String 转换为 Date —— 如何快速将字符串解析成日期?以下给出几种实现方式,随后我们来做个对比。

1、使用SimpleDateFormat

SimpleDateFormat 是我们使用最广泛、最容易想到的方式,需要注意的是 SimpleDateFormat 不是线程安全的,并发场景下要 sync 同步处理。
static final ThreadLocal<SimpleDateFormat> formatThreadLocal = ThreadLocal.withInitial( () -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
// get format from ThreadLocalSimpleDateFormat format = formatThreadLocal.get();format.parse(str);

2、使用java.time.DateTimeFormatter

JDK8 提供了 java.time API,吸收了 joda-time[4]的部分精华,功能更强大,性能也更好。同时,DateTimeFormatter 是线程安全的。
static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// use formatter parse DateLocalDateTime ldt = LocalDateTime.parse(str, formatter);ZoneOffset offset = DEFAULT_ZONE_ID.getRules().getOffset(ldt);long millis = ldt.toInstant(offset).toEpochMilli();Date date = new Date(millis);

这种方法比使用 SimpleDateFormat 组合 ThreadLocal 代码更简洁,速度也大约要快 50%。

图片源自github

3、针对固定格式和固定时区优化

我们在日常处理 Date 数据时,在国内最常见的格式就是 "yyyy-MM-dd HH:mm:ss",默认的时区为东 8 区,在 java.time 中的 ZonedId 是 "Asia/Shanghai"(而不是 Asia/Beijing),而东 8 区在1992年之后,不在使用夏令时,固定的 zoneOffset 是 +8,根据这个情况,我们可以针对性做优化,如下(为方便理解,以下为源码的简化版,去掉了影响阅读的边界处理等逻辑):
public static Date parseYYYYMMDDHHMMSS19(String str) { char y0 = str.charAt(0); char y1 = str.charAt(1); char y2 = str.charAt(2); char y3 = str.charAt(3); char m0 = str.charAt(4); char m1 = str.charAt(5); ... char s1 = str.charAt(18);
int year = (y0 - '0') * 1000 + (y1 - '0') * 100 + (y2 - '0') * 10 + (y3 - '0'); int month = (m0 - '0') * 10 + (m1 - '0'); int dom = (d0 - '0') * 10 + (d1 - '0'); int hour = (h0 - '0') * 10 + (h1 - '0'); int minute = (i0 - '0') * 10 + (i1 - '0'); int second = (s0 - '0') * 10 + (s1 - '0');
//换算成毫秒 long millis; if (year >= 1992 && (DEFAULT_ZONE_ID == SHANGHAI_ZONE_ID || DEFAULT_ZONE_ID.getRules() == IOUtils.SHANGHAI_ZONE_RULES)) {
final int DAYS_PER_CYCLE = 146097; final long DAYS_0000_TO_1970 = (DAYS_PER_CYCLE * 5L) - (30L * 365L + 7L);
long y = year; long m = month;
long epochDay; { long total = 0; total += 365 * y; total += (y + 3) / 4 - (y + 99) / 100 + (y + 399) / 400; total += ((367 * m - 362) / 12); total += dom - 1; if (m > 2) { total--; boolean leapYear = (year & 3) == 0 && ((year % 100) != 0 || (year % 400) == 0); if (!leapYear) { total--; } } epochDay = total - DAYS_0000_TO_1970; } long seconds = epochDay * 86400 + hour * 3600 + minute * 60 + second - SHANGHAI_ZONE_OFFSET_TOTAL_SECONDS;
millis = seconds * 1000L; } else { LocalDate localDate = LocalDate.of(year, month, dom); LocalTime localTime = LocalTime.of(hour, minute, second, 0); LocalDateTime ldt = LocalDateTime.of(localDate, localTime); ZoneOffset offset = DEFAULT_ZONE_ID.getRules().getOffset(ldt); millis = ldt.toEpochSecond(offset) * 1000; }
return new Date(millis);}


核心逻辑就是根据位数,直接开始计算给定的时间字符串,相对于参照的原点时间(1970-1-1 0点)过去了多少毫秒,这个优化,避免了parse Number的开销,精简了大量 Partten 的处理,处理流程非常高效。

4、性能测试 & 结论

benchmark[5]
Benchmark 
Mode
Cnt
Score
Error
Units
DateParse.simpleDateFormatParse
avgt(平均耗时)
5
11.540
4.170
us/ms
DateParse.dateTimeFormatterParse
avgt
5
7.594
0.200
us/ms
DateParse.parseYYYYMMDDHHMMSS19
avgt
5
0.425
0.098
us/ms

JMH测试显示:方法 3 的耗时远低于其他方式,方法 3 这种针对性的类型解析优化可以使用在重度使用日期解析的优化场景,比如数据批量导入解析日期,大数据场景的 UDF 日期解析等。

One more thing

fastjson 系列相比同类 json 处理工具,虽然在安全性、鲁棒性等方面还可以提升,但其最大优势——处理速度,却使其他同类竞品望尘莫及。我们也可以在日常业务处理中,学习其精华部分,运用其中的技术亮点,优化业务处理速度,提升用户体验。
参考链接:

[1]https://github.com/alibaba/fastjson2

[2]https://github.com/alibaba/fastjson2/wiki/fastjson_benchmark

[3]https://so.csdn.net/so/search?q=零拷贝&spm=1001.2101.3001.7020

[4]https://www.joda.org/joda-time/

[5]https://github.com/alibaba/fastjson2/blob/main/benchmark/src/main/java/com/alibaba/fastjson2/benchmark/DateParse.java

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存